Scrawl.php

<?php

namespace Tlf;

/**
 * Central class for running scrawl.
 */
class Scrawl {



    /** array for get/set */
    public array $stuff = [];

    public array $extensions = [
        'code'=>[],
    ];
    /**
     * Array of \Tlf\Scrawl\Extension objects
     */
    public array $ScrawlExtensions = [];

    /**
     * \Tlf\Scrawl\Ext\MdVerbs
     */
    public \Tlf\Scrawl\Ext\MdVerbs $mdverb_ext;

    /** absolute path to php file to `require()` before scrawl runs */
    public ?string $file_bootstrap = null;

    /** absolute path to your documentation dir */
    public ?string $dir_docs = null;

    /** absolute path to the root of your project */
    public ?string $dir_root = null;

    /** absolute path to the documentation source dir */
    public ?string $dir_src = null;

    /** array of relative path to dirs to scan, within your dir_root */
    public ?array $dir_scan = [];

    /** array of handlers for the mdverb extension */
    public array $verb_handlers = [];

    /** absolute path to directories that contain md templates */
    public array $template_dirs = [];

    /** if true, append two spaces to every line so all new lines are parsed as new lines */
    public bool $markdown_preserveNewLines = true;

    /** if true, add an html comment to md docs saying not to edit directly */
    public bool $markdown_prependGenNotice = true;

    /** if true, copies `docs/README.md` to project root `README.md` */
    public bool $readme_copyFromDocs = true;

    /** If true will delete all files in your docs dir before running */
    public bool $deleteExistingDocs = false;

    /** Which directory to write Class/Method/Property info to */
    public ?string $api_output_dir = 'api/';
    /** True to generate a README listing all classes, inside the api_output_dir */
    public bool $api_generate_readme = true;

    /**
     * Array of values, typically passed in through cli
     */
    public array $options = [];

    /**
     * @parma $options `key=>value` array to set properties
     */
    public function __construct(array $options=[]){
        $this->options = $options;
        foreach ($this->options as $k=>$o){
            $k = str_replace('.','_',$k);
            if (property_exists($this,$k)){
                $this->$k = $o;
            }
        }

        $this->template_dirs[] = __DIR__.'/Template/';
        $this->mdverb_ext = $this->setup_mdverb_ext();
        $this->ScrawlExtensions = $this->setup_extensions($this->options['ScrawlExtensions']??[]);
    }

    /**
     * Get the relative path within target_path, if it starts with root_path
     *
     * @experimental has not been tested
     * @param $base_path the base path, to remove from target path
     * @param $target_path a path that starts with `$base_path` and contains a relative path you're parsing.
     * @return the relative path, or null if target path does not start with base path
     */
    public function parse_rel_path(string $base_path, string $target_path, bool $use_realpath = true): ?string {
        $abs_base = $use_realpath ? realpath($base_path) : $base_path;
        $abs_target = $use_realpath ? realpath($target_path) : $target_path;
        $len = strlen($abs_base);
        // var_dump($abs_base);
        // var_dump($abs_target);
        if (substr($abs_target, 0, $len) != $abs_base)return null;

        return substr($abs_target,$len);
    }

    /**
     *
     * Cli function to get the absolute path to a code file
     *
     * @param $args array of arguments
     * @key 1, code_file_path -  absolute path to documentation of a code file. Example "/absolute/path.php" might return "/absolute/docs/path.md"
     * @usage `$scrawl->get_doc_path(new \Tlf\Cli(), "/absolute/path");
     *
     * @return absolute path or an empty string
     */
    public function get_doc_path(\Tlf\Cli $cli, array $args): string {
        $code_file_path = $args['--'][0];
        // echo "\nPath: $code_file_path\n";
        // echo "\nRealPath: ".realpath($code_file_path)."\n";
        $code_file_path = realpath($code_file_path);
        // print_r($args);

        $cwd = getcwd();
        // echo "\nCurDir: ".$cwd."\n";
        if ($cwd!=substr($code_file_path,0,strlen($cwd))){
            
            return "";
        }
        $rel_path = substr($code_file_path,strlen($cwd));
        $rel_path = str_replace('../','/', $rel_path);


        $abs_path = $this->dir_docs.($this->api_output_dir??'api/').$rel_path.'.md';
        $abs_path = str_replace('//','/', $abs_path);
        echo "$abs_path";
        return $abs_path;
        // echo "AbsPath:".$abs_path;
//
        // echo "\nRelPath: $rel_path\n";
//
//
        // return "\nPath: $code_file_path\n";

    }

    /**
     *
     * Cli function to get the absolute path to a documentation source file for a .md file 
     *
     * @param $args array of arguments
     * @key 1, code_file_path -  absolute path to documentation of a code file. Example "/absolute/path.php" might return "/absolute/docs/path.md"
     * @usage `$scrawl->get_doc_path(new \Tlf\Cli(), "/absolute/path");
     *
     * @return absolute path or an empty string
     */
    public function get_doc_source_path(\Tlf\Cli $cli, array $args): string {
        $code_file_path = $args['--'][0];
        // echo "\nPath: $code_file_path\n";
        // echo "\nRealPath: ".realpath($code_file_path)."\n";
        $code_file_path = realpath($code_file_path);
        // print_r($args);

        $cwd = getcwd();
        // echo "\nCurDir: ".$cwd."\n";
        if ($cwd!=substr($code_file_path,0,strlen($cwd))){
            return "";
        }
        if ($code_file_path == realpath($this->dir_root.'/README.md')){
            $path = $this->dir_src.'/README.src.md';
            echo $path;
            return $path;
        }
        
        $rel_path = substr($code_file_path,strlen($this->dir_docs));
        $rel_path = str_replace('../','/', $rel_path);


        $abs_path = $this->dir_src.'/'.$rel_path;
        $abs_path = str_replace('//','/', $abs_path);
        // replace .md with .src.md
        $abs_path = substr($abs_path, 0,-3) . '.src.md';
        echo "$abs_path";
        return $abs_path;
        // echo "AbsPath:".$abs_path;
//
        // echo "\nRelPath: $rel_path\n";
//
//
        // return "\nPath: $code_file_path\n";

    }


    /**
     * Get a template stored on disk. `$name` should be relative path without extension. `$args` is passed to template code, but not `extract`ed. Template files must end with `.md.php` or just `.php`.
     *
     */
    public function get_template(string $name, array $args){

        foreach ($this->template_dirs as $path){
            if (file_exists($file = $path.'/'.$name.'.md.php')){}
            else if (file_exists($file=$path.'/'.$name.'.php')){}
            else continue;
            $out = (function(array $args, string $file) {
                ob_start();
                require($file);
                $out = ob_get_clean();
                return $out;
            })($args, $file);
            return $out;
        }
        
        $this->warn("@template", $msg="Template '$name' does not exist.");
        return $msg;
    }

    public function get(string $group, string $key){
        if (!isset($this->stuff[$group])){
            $this->warn("Group not set", $group);
            return null;
        } else if (!isset($this->stuff[$group][$key])){
            $this->warn("Group.Key not set", "$group.$key");
            return null;
        }
        return $this->stuff[$group][$key];
    }

    public function get_group(string $group){
        if (!isset($this->stuff[$group])){
            $this->warn("Group not set", $group);
            return null;
        }
        return $this->stuff[$group];
    }


    public function set(string $group, string $key, $value){
        $this->stuff[$group][$key] = $value;
    }

    public function parse_str($str, $ext){
        $out = [];
        foreach ($this->extensions['code'][$ext] as $ext){
            $out = $ext->parse_str($str); 
        }
        return $out;
    }

    /**
     * save a file to disk in the documents directory
     */
    public function write_doc(string $rel_path, string $content){
        $content = $this->prepare_md_content($content);
        $rel_path = str_replace('../','/', $rel_path);
        $path = $this->dir_docs.'/'.$rel_path;
        $dir = dirname($path);
        if (!is_dir($dir))mkdir($dir,0755,true);
        if (is_file($path)){
            $this->good('Overwrite',$rel_path);
        } else {
            $this->good('Write',$rel_path);
        }
        file_put_contents($path, $content);
    }

    /**
     * save a file to disk in the root directory
     */
    public function write_file(string $rel_path, string $content){
        $rel_path = str_replace('../','/', $rel_path);
        $path = $this->dir_root.'/'.$rel_path;
        $dir = dirname($path);
        if (!is_dir($dir))mkdir($dir,0755,true);
        if (is_file($path)){
            $this->good('Overwrite',$rel_path);
        } else {
            $this->good('Write',$rel_path);
        }
        file_put_contents($path, $content);
    }

    /**
     * Read a file from disk, from the project root
     */
    public function read_file(string $rel_path){
        return file_get_contents($this->dir_root.'/'.$rel_path);
    }
    /**
     * Read a file from disk, from the project docs dir
     */
    public function read_doc(string $rel_path){
        return file_get_contents($this->dir_docs.'/'.$rel_path);
    }

    /** get a path to a docs file */
    public function doc_path(string $rel_path){
        return $this->dir_docs.'/'.$rel_path;
    }


    /**
     * Output a message to cli (may do logging later, idk)
     */
    public function report(string $msg){
        echo "\n$msg";
    }

    /**
     * Output a message to cli, header highlighted in red
     */
    public function warn($header, $message){
        echo "\033[0;31m$header:\033[0m $message\033[0;31m\033[0m\n";
    }

    /**
     * Output a message to cli, header highlighted in red
     */
    public function good($header, $message){
        echo "\033[0;32m$header:\033[0m $message\033[0;31m\033[0m\n";
    }

    /** apply small fixes to markdown */
    public function prepare_md_content(string $markdown){

        if ($this->markdown_preserveNewLines){
            $markdown  = str_replace("\n","  \n",$markdown);
        }

        if ($this->markdown_prependGenNotice){
            // @TODO give relative path to source file
            $markdown = "<!-- DO NOT EDIT. This file generated from template by Code Scrawl https://tluf.me/php/code-scrawl/ -->  \n".$markdown;
        }
        
        return $markdown;
    }


    public function get_all_docsrc_files(){
        $files = \Tlf\Scrawl\Utility\Main::allFilesFromDir($this->dir_src, '');
        return $files;
    }

    /** 
     * get array of all files in `$scrawl->dir_scan` 
     * @return array or relative paths within `$scrawl->dir_scan` 
     */
    public function get_all_scan_files(): array{
        $all = [];
        foreach ($this->dir_scan as $f){
            $files = \Tlf\Scrawl\Utility\Main::allFilesFromDir($this->dir_root, $f);
            $all = array_merge($all, $files);
        }
        return $all;
    }

    /**
     * Generate api docs for all files
     * (currently only php files)
     */
    public function generate_apis() {

        if ($this->api_output_dir === null
            || $this->api_output_dir === false
        )return;

        foreach ($this->get_all_scan_files() as $file){
            $this->generate_api($file);
        }

        if ($this->api_generate_readme){
            $this->generate_apis_readme();
        }
    }

    /**
     * Create a README file that lists all of the classes in the API dir.
     */
    public function generate_apis_readme(){
        $this->report("Generate README for APIs");

        $markdown = $this->get_template('ast/ApiReadme', [$this->get_all_classes(), $this->get_all_traits()]);

        $this->write_doc($this->api_output_dir.'/README.md', $markdown);

    }

    /**
     * Generate api doc for a single file
     * (currently only php files)
     *
     * @param $rel_path relative path inside `$scrawl->dir_root` 
     */
    public function generate_api($rel_path){
        if (strtolower(pathinfo($rel_path,PATHINFO_EXTENSION))!=='php')return;
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $ast = $php_ext->parse_file($rel_path);

        $path = $ast['path'];
        // $rel_path = substr($path, strlen($scrawl->dir_root));
        // $ast['path'] = $rel_path;

        // $scrawl->set('ast','file.'.$rel_path, $ast);

        $classes = array_merge($ast['class'] ?? [], $ast['namespace']['class'] ?? []);

        if (count($classes)==0)return;
        $doc = "# File ".$rel_path."\n";
        foreach ($classes as $c){
            $markdown = $this->get_template('ast/class', [null,$c,null]);

            $doc .="\n".$markdown;
        }
        $this->write_doc($this->api_output_dir.'/'.$rel_path.'.md', $doc);
    }

    /**
     * Get an array of all classes scanned within this repo. 
     *
     * @return array<string fully_qualified_classname, array $ast> ASTs for each class scanned within the repo.
     */
    public function get_all_classes(): array {
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $files = $this->get_all_scan_files();
        $classes = [];
        $trait_count = 0;
        foreach ($files as $f){
            if (strtolower(pathinfo($f,PATHINFO_EXTENSION))!=='php')continue;
            $this->report("Generate Ast: ".$f);
            $ast = $php_ext->parse_file($f);

            $trait_count += count($ast['trait']??[]) + count($ast['namespace']['trait']??[]);
            $new_classes = array_merge($ast['class'] ?? [], $ast['namespace']['class'] ?? []);
            foreach ($new_classes as &$c){
                $c['file'] = $f;
                $classes[$c['fqn']] = $c;
            }
        }

        return $classes;
    }

    /**
     * Get an array of all classes scanned within this repo. 
     *
     * @return array<string fully_qualified_classname, array $ast> ASTs for each class scanned within the repo.
     */
    public function get_all_traits(): array {
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $files = $this->get_all_scan_files();
        $traits = [];
        $trait_count = 0;
        foreach ($files as $f){
            if (strtolower(pathinfo($f,PATHINFO_EXTENSION))!=='php')continue;
            $this->report("Generate Ast: ".$f);
            $ast = $php_ext->parse_file($f);

            $new_traits = array_merge($ast['trait'] ?? [], $ast['namespace']['trait'] ?? []);
            foreach ($new_traits as &$c){
                $c['file'] = $f;
                $traits[$c['fqn']] = $c;
            }
        }

        return $traits;
    }

    /**
     * Array of \Tlf\Scrawl\Extension objects
     * @param $extensions_classes an array of class names implementing `\Tlf\Scrawl\Extension`
     * @return array of instantiated objects
     */
    public function setup_extensions(array $extension_classes){

        $extensions = [];
        foreach ($extension_classes as $ec){
            if (!class_exists($ec, true)){
                $this->report("Extension class '$ec' does not exist.");
                continue;
            }
            /* if (substr($ec,0,1)=='\\')$ec = substr($ec,1); */
            if (!in_array('Tlf\\Scrawl\\Extension',class_implements($ec,true))){
                $this->report("Extension class '$ec' does not exist.");
                continue;
            }
            $extensions[] = new $ec($this);

        }

        return $extensions;
    }

    /**
     * Execute scrawl in its entirety
     */
    public function run(){

        // bootstrap via file
        $scrawl = null;
        if ($this->file_bootstrap!=null&&file_exists($this->file_bootstrap)){
            $scrawl = $this;
            require_once($this->file_bootstrap);
        }
        unset($scrawl);

        // bootstrap extensions
        foreach ($this->ScrawlExtensions as $se){
            $se->bootstrap();
        }

        // delete existing documentation
        if ($this->deleteExistingDocs){
            $del_dir = realpath($this->dir_docs);
            $cwd = realpath(getcwd());
            $len = strlen($cwd);
            if (substr($del_dir,0,$len)===$cwd
                && strlen($cwd)>6
                &&count(explode('/',$cwd))>=4
            ){
                $this->warn("Delete Dir", $del_dir);
                \Tlf\Scrawl\Utility\Main::DANGEROUS_removeNonEmptyDirectory($del_dir);
            }
        }
        $this->report("Generate Asts");
        //////////
        // process php files into 'ast'
        //////////
        $classes = $this->get_all_classes();
        foreach ($classes as $c){
            $this->set('ast', 'class.'.$c['fqn'], $c);
            foreach ($this->ScrawlExtensions as $se){
                $se->ast_generated($c['fqn'], $c);
            }
        }
        $this->report("### Generate APIs ###\n");
        $this->generate_apis();


        // call extensions for ast generated
        foreach ($this->ScrawlExtensions as $se){
            $se->astlist_generated($this->get_group('ast')??[]);
        }


        //////////
        // process all code files using code extensions
        //////////
        $export_docblock = new \Tlf\Scrawl\FileExt\ExportDocBlock();
        $export_startend = new \Tlf\Scrawl\FileExt\ExportStartEnd();
        // $php_ext = new \Tlf\Scrawl\FileExt\Php();

        $code_files = $this->get_all_scan_files();

        // call extensions for all files
        foreach ($this->ScrawlExtensions as $se){
            $se->scan_filelist_loaded($code_files);
        }

        // var_dump($code_files);
        // exit;
        foreach ($code_files as $f){
            $path = $this->dir_root.'/'.$f;
            $file_content = file_get_contents($path);
            $file_exports = [];
            // docblock @export()s
            $docblocks = $export_docblock->get_docblocks($file_content);
            $exports = $export_docblock->get_exports($docblocks);
            foreach ($exports as $k=>$e)$this->set('export',$k, $e);
            $file_exports = $exports;
            // @export_start/@export_end()
            $exports = $export_startend->get_exports($file_content);
            $file_exports = array_merge($file_exports, $exports);
            foreach ($exports as $k=>$e)$this->set('export',$k, $e);


            foreach ($this->ScrawlExtensions as $se){
                $se->scan_file_processed($path, $f, $file_content, $file_exports??[]);
            }
        }

        foreach ($this->ScrawlExtensions as $se){
            $se->scan_filelist_processed($code_files, $this->get_group('export') ?? []);
        }

        //////////
        // process all documentation source files
        //////////
        $src_files = $this->get_all_docsrc_files();

        $mdverb_ext = $this->mdverb_ext;

        foreach ($this->ScrawlExtensions as $se){
            $se->doc_filelist_loaded($src_files, $mdverb_ext);
        }

        foreach ($src_files as $sf){
            $path = $this->dir_src.'/'.$sf;
            // $sf contains a leading slash, i guess?
            if ($path==$this->dir_src.'//config.json'){
                continue;
            }
            $content = file_get_contents($path);
            // process mdverbs

            foreach ($this->ScrawlExtensions as $se){
                $se->doc_file_loaded($path,$sf,$content);
            }

            $content = $mdverb_ext->replace_all_verbs($content);
            if (substr($sf,-7)=='.src.md')$sf = substr($sf,0,-7).'.md';

            $this->write_doc($sf, $content);

            foreach ($this->ScrawlExtensions as $se){
                $se->doc_file_processed($this->dir_docs.'/'.$sf,$sf,$content);
            }
        }


        foreach ($this->ScrawlExtensions as $se){
            $se->doc_filelist_processed($src_files);
        }


        $readme_path = $this->doc_path('README.md');
        if ($this->readme_copyFromDocs && file_exists($readme_path)){
            $this->write_file('README.md', $this->read_doc('README.md'));
        }

        $this->good("Finished",'Code Scrawl Ran');


        foreach ($this->ScrawlExtensions as $se){
            $se->scrawl_finished();
        }
    }

    /**
     * get an array ast from a file
     * Currently only supports php files
     * Also sets the ast to scrawl
     */
    public function get_ast(string $file): ?array {
        $ext = pathinfo($file, PATHINFO_EXTENSION);
        if ($ext!='php'){
            $this->report("File '$file' not .php. Skip ast parse.");
            return null;
        }
        $php_ext = new \Tlf\Scrawl\FileExt\Php($this);
        $ast = $php_ext->parse_file($file);
        // $php_ext->set_ast($ast);
        return $ast;
    }


    /** get the class ast
     * @param $class fully qualified class name
     */
    public function get_class_ast(string $class): ?array{
       $ast = $this->get('ast','class.'.$class); 
       return $ast;
    }


    public function setup_mdverb_ext(){
        $mdverb_ext = new \Tlf\Scrawl\Ext\MdVerbs($this);
        $main_verbs_ext = new \Tlf\Scrawl\Ext\MdVerb\MainVerbs($this);
        $main_verbs_ext->setup_handlers($mdverb_ext);

        $ast_ext = new \Tlf\Scrawl\Ext\MdVerb\Ast($this);
        $mdverb_ext->handlers['ast'] = [$ast_ext, 'get_markdown'];
        foreach ($this->verb_handlers as $k=>$v){
            $mdverb_ext->handlers[$k] = $v;
        }
        return $mdverb_ext;
    }

    // public function call_extensions($hook){
//
    // }

}